Repository navigation
Conversation
| fn try_pipe_transport() -> Option<(StdioReader, StdioWriter)> { | ||
| use std::os::fd::AsFd; | ||
|
|
||
| let read = std::io::stdin().as_fd().try_clone_to_owned().ok()?; | ||
| let write = std::io::stdout().as_fd().try_clone_to_owned().ok()?; | ||
| pipe_transport(read, write) | ||
| } |
There was a problem hiding this comment.
try_clone_to_owned() duplicates the file descriptor, so the new fd shares its open file description with fd 0 or 1. When from_owned_fd sets O_NONBLOCK, the process's stdin or stdout stays non-blocking for the rest of its life, even after the transport is dropped.
|
Good catch - you're right that What the code does guard against is the partial case: both descriptors are verified as FIFOs before either is converted, so the fallback path can never receive a description that has already been flipped, and the two halves are always converted together. Options if you want the process-wide effect contained:
I can implement either; my lean is 2 plus a doc note, since 1 can only restore the flag after the fact and has the split-transport edge above. But you know the SDK's expectations better - happy to go your way. |
|
@itsmunzir Thanks for the careful analysis. The split-transport point about option 1 is a good one. On Linux, we can avoid the leak entirely. Opening I'd rather not rely on a caveat in the docs, since Could you add an Ubuntu test to check that fd 0's flags are unchanged after |
|
Done in Tests added:
Verified here: Windows tests 218/218, the Linux target compiles, and clippy passes with the CI feature sets. The Linux tests run on CI. |
|
Gentle ping on this one. The /proc/self/fd rework is pushed in 8f1193b and CI is green — the Linux path reopens fd 0/1 so the original descriptors keep their flags, with the blocking fallback on macOS and Windows. Happy to adjust anything before review. |
|
The commitlint failure is fixed: the rework commit body had 221/274/167-character lines, over the 100-character On Check MSRV: this is dependency drift, not this PR. The job reads |
8f1193b to
bc5160b
Compare
|
The MSRV failure isn't from your change. It was fixed on |
`transport::stdio()` reads and writes through Tokio's blocking thread pool, so every message on a piped stdio connection pays a cross-thread hand-off. MCP clients launch stdio servers with FIFOs for stdin/stdout, which Tokio's Unix pipe driver can drive from the IO reactor instead. `stdio_pipes()` wraps the standard streams as non-blocking pipes when both are FIFOs and otherwise falls back to the blocking `stdio()` handles, so the returned `(StdioReader, StdioWriter)` pair still converts into a transport on every platform. TTYs, files, sockets, and Windows keep today's behavior. `stdio()` is left unchanged because changing its return type would be a breaking public API change; this constructor is additive. The `transport-io` feature now enables `tokio/net` for the pipe types. Refs modelcontextprotocol#1301
try_clone_to_owned duplicates the descriptor, so the duplicate shares its open file description with fd 0 or 1. Tokio's O_NONBLOCK then leaked into the process's own standard streams, and into children that inherit them. stdio_pipes() now opens /proc/self/fd/0 and /1 with O_NONBLOCK on Linux. The kernel gives the new descriptors their own file descriptions, so the flags stay private to the transport. macOS has no /proc, so it keeps the blocking fallback; the pipe variant is Linux-only now. Tests: reopening a FIFO keeps the source descriptor blocking, descriptor 0 keeps its flags across stdio_pipes(), and the round-trip and non-FIFO cases moved to Linux. Signed-off-by: itsmunzir <itsmunzir@users.noreply.github.com>
bc5160b to
f2e2d6b
Compare
| let stdin = std::io::stdin().as_fd().try_clone_to_owned().ok()?; | ||
| if !is_fifo(&stdin) { | ||
| return None; | ||
| } | ||
| let stdout = std::io::stdout().as_fd().try_clone_to_owned().ok()?; | ||
| if !is_fifo(&stdout) { | ||
| return None; | ||
| } | ||
|
|
||
| let read = reopen_nonblocking("/proc/self/fd/0", false)?; | ||
| let write = reopen_nonblocking("/proc/self/fd/1", true)?; |
There was a problem hiding this comment.
try_pipe_transport only duplicates file descriptors 0 and 1 to call fstat on them. Then is_fifo duplicates them again, and the code later opens /proc/self/fd/N. So the descriptor it checks isn’t the one it uses. Is there a reason not to reopen the descriptors first and run is_fifo on the ones we actually pass to Tokio?
| /// Reader half of the transport returned by [`stdio_pipes`]. | ||
| #[non_exhaustive] | ||
| pub enum StdioReader { | ||
| /// Blocking standard input, as returned by [`stdio`]. | ||
| Blocking(tokio::io::Stdin), | ||
| /// Non-blocking standard input backed by a Linux pipe. | ||
| #[cfg(target_os = "linux")] | ||
| Pipe(tokio::net::unix::pipe::Receiver), | ||
| } |
There was a problem hiding this comment.
StdioReader and StdioWriter are public, along with their variants. That makes tokio::net::unix::pipe::Receiver part of rmcp's API, and the available variants vary by target OS. Do callers need to know which path was taken?
| /// The check the reviewer asked for: descriptor 0 keeps its flags. | ||
| #[cfg(target_os = "linux")] | ||
| #[tokio::test] | ||
| async fn stdio_pipes_leaves_descriptor_zero_flags_alone() { | ||
| use std::os::fd::AsRawFd; | ||
|
|
||
| use super::stdio_pipes; | ||
|
|
||
| let before = open_flags(std::io::stdin().as_raw_fd()); | ||
| let _transport = stdio_pipes(); | ||
| let after = open_flags(std::io::stdin().as_raw_fd()); | ||
| assert_eq!(before, after); | ||
| } |
There was a problem hiding this comment.
stdio_pipes() only reopens anything when both file descriptors 0 and 1 are FIFOs. Under cargo test on CI, stdin usually isn't a pipe, so the test can pass without exercising the code it's meant to cover. How can we make sure it actually goes through the reopen path on the Ubuntu runner?
| pub fn stdio_pipes() -> (StdioReader, StdioWriter) { | ||
| #[cfg(target_os = "linux")] | ||
| if let Some(pair) = try_pipe_transport() { | ||
| return pair; | ||
| } | ||
| ( | ||
| StdioReader::Blocking(tokio::io::stdin()), | ||
| StdioWriter::Blocking(tokio::io::stdout()), | ||
| ) | ||
| } |
There was a problem hiding this comment.
Every failure in the detection chain is swallowed by .ok()?. If someone reports that stdio_pipes() isn't faster, nothing tells us whether the pipe path was taken. Would a tracing event on fallback be worth it, since the crate already depends on tracing?
| } | ||
|
|
||
| /// Reader half of the transport returned by [`stdio_pipes`]. | ||
| #[non_exhaustive] |
There was a problem hiding this comment.
Public types should implement Debug, and all the wrapped Tokio types already do. The same goes for StdioWriter` below.
| #[non_exhaustive] | |
| #[derive(Debug)] | |
| #[non_exhaustive] |
| let _ = std::fs::remove_file(&path); | ||
| } | ||
|
|
||
| /// The check the reviewer asked for: descriptor 0 keeps its flags. |
There was a problem hiding this comment.
Once this is merged, "the reviewer" won't mean anything, so the comment should name the property being tested.
| /// The check the reviewer asked for: descriptor 0 keeps its flags. | |
| /// `stdio_pipes()` must not set `O_NONBLOCK` on the process's own stdin. |
| #[tokio::test] | ||
| async fn fifo_detection_rejects_character_devices() { |
There was a problem hiding this comment.
This test never uses .await, so a plain #[test] is enough. It also makes it clear that the test doesn't touch the reactor. The same goes for reopening_a_fifo_keeps_the_original_flags above.
| #[tokio::test] | |
| async fn fifo_detection_rejects_character_devices() { | |
| #[test] | |
| fn fifo_detection_rejects_character_devices() { |
| #[cfg(windows)] | ||
| #[tokio::test] | ||
| async fn stdio_pipes_falls_back_to_blocking_handles_on_windows() { |
There was a problem hiding this comment.
macOS now uses the same fallback in all cases, so this test can run on every non-Linux target.
| #[cfg(windows)] | |
| #[tokio::test] | |
| async fn stdio_pipes_falls_back_to_blocking_handles_on_windows() { | |
| #[cfg(not(target_os = "linux"))] | |
| #[tokio::test] | |
| async fn stdio_pipes_falls_back_to_blocking_handles_off_linux() { |
Summary
transport::stdio()returnstokio::io::Stdin/Stdout, and every read and write on those handles is dispatched to Tokio's blocking thread pool. For a piped stdio server, that is a cross-thread hand-off per message: #1301 measured ~41k req/s vs ~86k overtokio::net::unix::pipefor the same server.This adds
transport::stdio_pipes()(behind the existingtransport-iofeature):On Unix, when both standard streams are FIFOs — how MCP clients launch stdio servers — it duplicates the descriptors and wraps them as
tokio::net::unix::pipe::{Receiver, Sender}, driven by the IO reactor.Otherwise (TTY, file, socket, non-Unix platform) it falls back to the existing blocking
stdio()handles.It returns
(StdioReader, StdioWriter), public enums implementingAsyncRead/AsyncWritethat convert into a transport through the existingIntoTransporttuple impl, so call sites stay portable:stdio()is intentionally left unchanged: changing its return type is a breaking public API change (seeVERSIONING.mdand thepublic-apiCI job). If maintainers preferstdio()itself to use the fast path, that needs a major release — happy to follow up either way.Design notes
fstat-checked (S_IFIFO) before either is handed to Tokio.from_owned_fdswitches the descriptor toO_NONBLOCK, which is shared across descriptors of the same open file description, so a partial conversion would leave the blocking fallback with non-blocking stdio.stdio_pipes()must be called inside a Tokio runtime with IO enabled (Tokio'sfrom_owned_fdpanics otherwise); this is documented on the function.transport-ionow enablestokio/netfor the pipe types. Precedent:transport-streamable-http-client-unix-socketalready enablestokio/net.Tests
pipe_transport_round_trips_over_fifos(unix): spawnscat, wraps realStdio::piped()FIFO descriptors through the same code path, round-trips a JSON-RPC line, and checks the peer exits cleanly.pipe_transport_falls_back_for_non_fifos(unix): character devices are rejected.stdio_pipes_builds_an_async_rw_transport: the returned pair satisfies theAsyncRwTransport/IntoTransportbounds used byserve.stdio_pipes_falls_back_to_blocking_handles_on_windows.Verification
cargo test -p rmcp --lib→ 216 passed.cargo test -p rmcp --lib --features transport-io→ 218 passed (Windows).cargo check --target x86_64-unknown-linux-gnu --lib --tests --features transport-io; they execute on CI (ubuntu).cargo clippy -p rmcp --all-targets --all-features -- -D warnings→ clean.cargo clippy -p rmcp --all-targets --no-default-features --features <all except local> -- -D warnings→ clean.cargo +nightly fmt→ clean for the changed files.No throughput numbers are claimed here; #1301's harness (Apple M3 Pro) is the reference, and this change was developed on Windows.
Closes #1301
Contribution prepared with an AI coding agent (opencode); tests were written first and fail without the fix.